-- -------------------------------------------------------------------------
-- UI/renderer.lua
-- Rendering Engine
-- -------------------------------------------------------------------------

local renderer = {}
local state = require("ClickUl.UI.state")

-- [Lua 5.4] <const> for global type references
local vec2 <const> = vec2
local color <const> = color

-- [Lua 5.4] <const> for function references
local math_floor <const> = math.floor
local string_len <const> = string.len
local string_find <const> = string.find
local string_sub <const> = string.sub
local tostring <const> = tostring
local type <const> = type
local table_insert <const> = table.insert
local os_clock <const> = os.clock
local next <const> = next

local constants <const> = state.constants

-- [Color Cache Helpers]

local function clamp_byte(v)
    v = math_floor(v or 0)
    if v < 0 then return 0 end
    if v > 255 then return 255 end
    return v
end

local function color_hash(r, g, b, a)
    return r + (g * 256) + (b * 65536) + (a * 16777216)
end

-- [API Availability]
local has_gui = (gui ~= nil)
local has_rect_api = has_gui and (gui.rect ~= nil)
local has_text_api = has_gui and (gui.text ~= nil)
local has_text_size_api = has_gui and (gui.text_size ~= nil)
local has_justify_api = has_gui and (gui.justify ~= nil)

-- [Cached Enums]
local JUSTIFY_LEFT = has_justify_api and gui.justify.left or 0
local JUSTIFY_CENTER = has_justify_api and gui.justify.center or 2
local JUSTIFY_RIGHT = has_justify_api and gui.justify.right or 1

-- [Performance Cache]
local cached_scale_factor = 1.0

-- [Vec2 Cache]
local vec2_cache = {}
local vec2_cache_count = 0
local VEC2_CACHE_LIMIT <const> = 4096

local function get_vec2(x, y)
    local ix, iy = math_floor(x), math_floor(y)

    if ix < -30000 then ix = -30000 elseif ix > 30000 then ix = 30000 end
    if iy < -50000 then iy = -50000 elseif iy > 50000 then iy = 50000 end
    local key = (ix + 30000) * 100001 + (iy + 50000)

    local v = vec2_cache[key]
    if v then return v end

    v = vec2(ix, iy)
    vec2_cache[key] = v
    vec2_cache_count = vec2_cache_count + 1

    if vec2_cache_count > VEC2_CACHE_LIMIT then
        vec2_cache = {}
        vec2_cache_count = 0
        vec2_cache[key] = v
        vec2_cache_count = 1
    end

    return v
end

-- [Color Alpha Cache]
local COLOR_ALPHA_CACHE_LIMIT <const> = 2000
local color_alpha_cache_count = 0

-- [Clean label cache]
local clean_label_cache = {}
local clean_label_count = 0
local CLEAN_LABEL_MAX <const> = 2048

-- [Text Object Cache]
local text_obj_cache = {}
local text_cache_order = {}  -- LRU tracking
local text_cache_count = 0
local TEXT_CACHE_LIMIT <const> = 1500

-- [Helper] Check if string is dynamic content (should NOT be cached)
local string_match <const> = string.match
local function is_dynamic_text(str)
    -- 1. Contains digits (counters, IDs, coordinates, percentages)
    --    Examples: "Frame: 123", "Online Players (5/32)", "Player [0]"
    if string_match(str, "%d") then return true end

    -- 2. Very long strings (likely generated/concatenated content)
    --    Static UI labels are typically short
    if string_len(str) > 64 then return true end

    -- 3. Contains ### stable ID marker (dynamic display, stable ID)
    --    Example: "Online Players (5/32)###PlayerList"
    --    The display part changes, only ID part is stable
    if string_match(str, "###") then return true end

    return false
end

-- [PERF OPT V4] Color alpha cache with gradual eviction
-- Helper to evict ~30% of cache entries instead of full clear
local function evict_color_alpha_cache()
    local cache = state.cache.color_alpha
    local evict_ratio = 0.3
    local evict_target = math_floor(color_alpha_cache_count * evict_ratio)
    local evicted = 0

    -- Preserve special keys like _transparent
    local preserved = { _transparent = cache._transparent }

    for key, val in pairs(cache) do
        if key ~= "_transparent" and evicted < evict_target then
            -- For nested tables (string keys with alpha buckets), just remove the whole bucket
            cache[key] = nil
            if type(val) == "table" then
                -- Count nested entries
                for _ in pairs(val) do evicted = evicted + 1 end
            else
                evicted = evicted + 1
            end
        end
        if evicted >= evict_target then break end
    end

    -- Restore preserved
    cache._transparent = preserved._transparent
    color_alpha_cache_count = color_alpha_cache_count - evicted
    if color_alpha_cache_count < 0 then color_alpha_cache_count = 0 end
end

function renderer.vn(v) return v or 0 end

-- [HOT PATH] Inline scale calculation
function renderer.s(val)
    return (val or 0) * cached_scale_factor
end

function renderer.reset_frame_cache()
    -- No-op with bulk flush strategy
end

function renderer.get_clean_label(str)
    if not str then return "" end
    local cached = clean_label_cache[str]
    if cached then return cached end

    local result = str
    local s_idx = string_find(str, "##", 1, true)
    if s_idx then result = string_sub(str, 1, s_idx - 1) end

    if clean_label_count >= CLEAN_LABEL_MAX then
        clean_label_cache = {}
        clean_label_count = 0
    end

    clean_label_cache[str] = result
    clean_label_count = clean_label_count + 1
    return result
end

-- [Scale System]

function renderer.update_scale()
    local now = os_clock()
    local ui = state.ui
    local cache = state.cache
    local config_sys = state.deps.config_sys

    -- Throttle check
    if now < cache.last_res_check and ui.scale_factor ~= 0 and not (ui.open and ui.active_tab == ui.settings_tab_index) then 
        return 
    end
    cache.last_res_check = now + constants.RES_CHECK_INTERVAL

    local res = { x = 1920, y = 1080 }
    if game and game.resolution then res = game.resolution() end

    if res.x ~= ui.screen_res.x or res.y ~= ui.screen_res.y or ui.scale_factor == 0 then
        ui.screen_res.x = res.x
        ui.screen_res.y = res.y
        ui.scale_factor = ui.screen_res.y / constants.SCREEN_DEFAULT_HEIGHT
        if ui.scale_factor < constants.SCREEN_MIN_SCALE then
            ui.scale_factor = constants.SCREEN_MIN_SCALE
        end
        cached_scale_factor = ui.scale_factor -- Update hot cache
    end

    if config_sys and config_sys.data then
        local b = config_sys.data.base
        local c = config_sys.data.current
        local s = renderer.s
        local vn = renderer.vn

        -- Batch update configs
        c.w = s(b.w); c.h = s(b.h)
        c.header_h = s(b.header_h); c.sidebar_w = s(b.sidebar_w)
        c.padding = s(b.padding); c.item_h = s(b.item_h)
        c.button_h = s(b.button_h or 40); c.slider_h = s(b.slider_h or 60)
        c.item_spacing = s(b.item_spacing); c.text_y_offset = s(b.text_y_offset or 2)

        c.font_header = vn(b.font_header); c.font_tab = vn(b.font_tab); c.font_subtab = vn(b.font_subtab)
        c.font_group = vn(b.font_group); c.font_widget = vn(b.font_widget)
        c.font_small = vn(b.font_small); c.font_notif = vn(b.font_notif)

        c.slider_height = s(b.slider_height); c.slider_track_offset = s(b.slider_track_offset)
        c.slider_extra_height = s(b.slider_extra_height)
        
        c.scrollbar_width = s(b.scrollbar_width); c.scrollbar_thumb_min = s(b.scrollbar_thumb_min)
        c.scrollbar_hit_width = s(b.scrollbar_hit_width)
        
        c.checkbox_width = s(b.checkbox_width); c.checkbox_height = s(b.checkbox_height)
        c.checkbox_knob_size = s(b.checkbox_knob_size); c.checkbox_knob_offset = s(b.checkbox_knob_offset)
        c.checkbox_track_rounding = s(b.checkbox_track_rounding); c.checkbox_knob_rounding = s(b.checkbox_knob_rounding)
        c.checkbox_use_toggle_style = b.checkbox_use_toggle_style == true
        c.toggle_width = s(b.toggle_width or 40); c.toggle_height = s(b.toggle_height or 22)
        c.toggle_knob_size = s(b.toggle_knob_size or 18); c.toggle_knob_offset = s(b.toggle_knob_offset or 2)
        c.toggle_track_rounding = s(b.toggle_track_rounding or 11); c.toggle_knob_rounding = s(b.toggle_knob_rounding or 9)

        c.widget_rounding = s(b.widget_rounding); c.widget_text_padding = s(b.widget_text_padding)
        c.group_header_height = s(b.group_header_height); c.group_content_padding = s(b.group_content_padding)
        c.group_spacing = s(b.group_spacing); c.group_rounding = s(b.group_rounding)

        c.sidebar_btn_margin = s(b.sidebar_btn_margin); c.sidebar_icon_size = s(b.sidebar_icon_size)
        c.sidebar_btn_spacing = s(b.sidebar_btn_spacing); c.sidebar_btn_rounding = s(b.sidebar_btn_rounding)

        c.subtab_height = s(b.subtab_height); c.subtab_gap = s(b.subtab_gap); c.subtab_rounding = s(b.subtab_rounding)
        c.subtab_spacing_after = s(b.subtab_spacing_after)
        c.inline_tab_height = s(b.inline_tab_height or 24); c.inline_tab_gap = s(b.inline_tab_gap or 4)
        c.inline_tab_indicator_height = s(b.inline_tab_indicator_height or 2)
        c.dual_column_gap = s(b.dual_column_gap); c.dual_column_bottom_margin = s(b.dual_column_bottom_margin)
        c.combo_max_visible = vn(b.combo_max_visible); c.combo_dropdown_gap = s(b.combo_dropdown_gap)
        c.combo_thumb_min = s(b.combo_thumb_min)
        c.scroll_speed = s(b.scroll_speed); c.scroll_group_speed = s(b.scroll_group_speed)

        c.click_anim_duration = vn(b.click_anim_duration); c.click_anim_alpha = vn(b.click_anim_alpha)
        c.content_padding_y = s(b.content_padding_y); c.window_content_offset = s(b.window_content_offset)
        c.divider_width = s(b.divider_width); c.divider_padding = s(b.divider_padding)
        c.separator_height = s(b.separator_height or 1); c.separator_margin = s(b.separator_margin or 8)
        c.progress_height = s(b.progress_height or 8); c.progress_rounding = s(b.progress_rounding or 4)
        c.row_gap = s(b.row_gap or 8)
        c.color_preview_height = s(b.color_preview_height); c.color_preview_rounding = s(b.color_preview_rounding)

        -- Menu Hints Overlay
        c.menu_hints_font_size = s(b.menu_hints_font_size or 16)
        c.menu_hints_offset_y = s(b.menu_hints_offset_y or 2)
        c.menu_hints_height = s(b.menu_hints_height or 30)
        c.menu_hints_padding = s(b.menu_hints_padding or 10)
        c.menu_hints_rounding = s(b.menu_hints_rounding or 8)

        c.window_rounding_tl = s(b.window_rounding_tl); c.window_rounding_tr = s(b.window_rounding_tr)
        c.window_rounding_bl = s(b.window_rounding_bl); c.window_rounding_br = s(b.window_rounding_br)
        c.header_rounding_tl = s(b.header_rounding_tl); c.header_rounding_tr = s(b.header_rounding_tr)
    end
end

function renderer.force_update_scale()
    state.cache.last_res_check = 0
    state.ui.scale_factor = 0
    -- [FIX V4] Clear text_width cache when scale changes to prevent layout errors
    state.cache.text_width = {}
    state.cache.text_width_size = 0
    renderer.update_scale()
end

-- [Color System]

function renderer.get_color(col_key)
    local cache = state.cache
    if type(col_key) == "string" then
        if cache.color_userdata[col_key] then return cache.color_userdata[col_key] end
        local c = state.deps.config_sys.data.colors[col_key]
        if c then
            local new_col = color(c.r, c.g, c.b, c.a)
            cache.color_userdata[col_key] = new_col
            return new_col
        end
        return color(255, 255, 255, 255)
    end
    -- Fallback for direct tables/userdata
    if type(col_key) == "userdata" then return col_key end
    return color(col_key.r or 255, col_key.g or 255, col_key.b or 255, col_key.a or 255)
end

function renderer.get_color_alpha(col_key, alpha_mult)
    if not alpha_mult or alpha_mult >= 0.99 then return renderer.get_color(col_key) end
    if alpha_mult <= 0.01 then
        -- Return a fully transparent color (cached)
        if not state.cache.color_alpha._transparent then
            state.cache.color_alpha._transparent = color(0, 0, 0, 0)
        end
        return state.cache.color_alpha._transparent
    end

    -- Optimize cache key using integer alpha (0-100)
    local a_idx = math_floor(alpha_mult * 100 + 0.5)

    local t = type(col_key)
    if t == "string" then
        local by_key = state.cache.color_alpha[col_key]
        if not by_key then
            by_key = {}
            state.cache.color_alpha[col_key] = by_key
        end
        if by_key[a_idx] then return by_key[a_idx] end

        local c = state.deps.config_sys.data.colors[col_key]
        local base = c and c.a or 255
        local new_col = c and color(c.r, c.g, c.b, math_floor(base * alpha_mult)) or renderer.get_color(col_key)

        -- [PERF OPT V4] Gradual eviction instead of full clear
        if color_alpha_cache_count >= COLOR_ALPHA_CACHE_LIMIT then
            evict_color_alpha_cache()
            -- Re-fetch by_key after eviction
            by_key = state.cache.color_alpha[col_key]
            if not by_key then
                by_key = {}
                state.cache.color_alpha[col_key] = by_key
            end
        end
        by_key[a_idx] = new_col
        color_alpha_cache_count = color_alpha_cache_count + 1
        return new_col
    elseif t == "table" then
        local r = clamp_byte(col_key.r or 255)
        local g = clamp_byte(col_key.g or 255)
        local b = clamp_byte(col_key.b or 255)
        local base_a = clamp_byte(col_key.a or 255)
        local a = clamp_byte(base_a * alpha_mult)

        -- Cache table-based colors too (prevents per-frame userdata churn).
        local hash_key = color_hash(r, g, b, a)
        local cached = state.cache.color_alpha[hash_key]
        if cached then return cached end

        cached = color(r, g, b, a)

        -- [PERF OPT V4] Gradual eviction instead of full clear
        if color_alpha_cache_count >= COLOR_ALPHA_CACHE_LIMIT then
            evict_color_alpha_cache()
        end
        state.cache.color_alpha[hash_key] = cached
        color_alpha_cache_count = color_alpha_cache_count + 1
        return cached
    end
    return renderer.get_color(col_key)
end

-- [Text Width]

function renderer.get_text_width(str, size)
    local cache = state.cache

    local size_key = size or 0
    local by_size = cache.text_width[size_key]
    if not by_size then
        by_size = {}
        cache.text_width[size_key] = by_size
    end
    if by_size[str] then return by_size[str] end

    local width = 0
    if has_text_size_api then
        local dims = gui.text_size(str, renderer.s(size), { wrap = 0 })
        width = dims and dims.x or 0
    else
        width = string_len(str) * (renderer.s(size) * constants.TEXT_WIDTH_ESTIMATE_FACTOR)
    end

    local CACHE_LIMIT = constants.TEXT_WIDTH_CACHE_LIMIT or 3000
    local EVICT_RATIO = constants.TEXT_WIDTH_CACHE_EVICT_PERCENTAGE or 0.3

    if cache.text_width_size >= CACHE_LIMIT then
        local evict_count = math_floor(CACHE_LIMIT * EVICT_RATIO)
        local evicted = 0

        -- Evict from each size bucket proportionally
        for _, bucket in pairs(cache.text_width) do
            local bucket_count = 0
            for _ in pairs(bucket) do bucket_count = bucket_count + 1 end

            -- Evict ~30% from each bucket
            local to_evict = math_floor(bucket_count * EVICT_RATIO)
            if to_evict > 0 then
                local removed = 0
                for key in pairs(bucket) do
                    if removed >= to_evict then break end
                    bucket[key] = nil
                    removed = removed + 1
                    evicted = evicted + 1
                end
            end

            if evicted >= evict_count then break end
        end

        cache.text_width_size = cache.text_width_size - evicted

        -- Need to re-fetch by_size since we may have modified it
        by_size = cache.text_width[size_key]
        if not by_size then
            by_size = {}
            cache.text_width[size_key] = by_size
        end
    end

    by_size[str] = width
    cache.text_width_size = cache.text_width_size + 1
    return width
end

local text_size_cache = {}
local text_size_cache_count = 0
local TEXT_SIZE_CACHE_LIMIT <const> = 500

--- Get text size (width and height) with optional wrap support
--- @param str string The text to measure
--- @param size number The font size
--- @param wrap_width number|nil Optional wrap width (nil or 0 for no wrap)
--- @return table {x=width, y=height} Text dimensions
function renderer.get_text_size(str, size, wrap_width)
    if not str then return {x = 0, y = 0} end

    -- Build cache key (using mathematical hash to avoid string concat GC)
    -- Key format: size * 1000000 + (wrap_width or 0)
    -- Then use str as nested key
    local size_key = size or 0
    local wrap_key = wrap_width or 0
    local numeric_key = math_floor(size_key * 1000000 + wrap_key)

    -- Two-level cache: numeric_key -> {str -> result}
    local by_params = text_size_cache[numeric_key]
    if not by_params then
        by_params = {}
        text_size_cache[numeric_key] = by_params
    end

    if by_params[str] then
        return by_params[str]
    end

    -- Calculate text size
    local dims = {x = 0, y = 0}
    if has_text_size_api then
        local opts = (wrap_width and wrap_width > 0) and {wrap = wrap_width} or {wrap = 0}
        local result = gui.text_size(str, renderer.s(size), opts)
        if result then
            dims.x = result.x or 0
            dims.y = result.y or 0
        end
    else
        -- Fallback estimation
        dims.x = string_len(str) * (renderer.s(size) * constants.TEXT_WIDTH_ESTIMATE_FACTOR)
        dims.y = renderer.s(size)
    end

    -- Simple LRU: clear cache when full
    if text_size_cache_count > TEXT_SIZE_CACHE_LIMIT then
        text_size_cache = {}
        text_size_cache_count = 0
        -- Re-create by_params
        by_params = {}
        text_size_cache[numeric_key] = by_params
    end

    by_params[str] = dims
    text_size_cache_count = text_size_cache_count + 1
    return dims
end

-- [Drawing Functions]

function renderer.draw_rect_ex(x, y, w, h, col_key, round, round_flags, alpha_mult, override_color_vec)
    if not has_rect_api then return end

    -- Integer rounding for sharper edges and better cache hits
    local rx, ry = math_floor(x), math_floor(y)
    local rw, rh = math_floor(w), math_floor(h)
    
    if rw <= 0 or rh <= 0 then return end

    local r_obj = gui.rect(get_vec2(rx, ry), get_vec2(rw, rh))
    r_obj:filled(true)

    if override_color_vec then
        local a = override_color_vec.a or 255
        if alpha_mult then a = a * alpha_mult end
        r_obj:color(color(override_color_vec.r or 255, override_color_vec.g or 255, override_color_vec.b or 255, a))
    else
        if alpha_mult and alpha_mult < 1.0 then
            r_obj:color(renderer.get_color_alpha(col_key, alpha_mult))
        else
            r_obj:color(renderer.get_color(col_key))
        end
    end

    if round and round > 0 then
        r_obj:rounding(math_floor(round), round_flags)
    end
    r_obj:draw()
end

function renderer.draw_rect(x, y, w, h, col_key, round, round_flags)
    renderer.draw_rect_ex(x, y, w, h, col_key, round, round_flags, 1.0)
end

function renderer.draw_text_ex(str, x, y, size, col_key, align_w, align_h, alpha_mult, override_color_vec, cache_key)
    if not has_text_api then return end

    local s_size = math_floor(renderer.s(size))
    local safe_str = tostring(str or "")
    local fx, fy = math_floor(x), math_floor(y)
    local use_justify = false

    -- Vertical alignment (always manual)
    if align_h and align_h > 0 then
        fy = fy + math_floor((align_h - s_size) * 0.5) - renderer.s(1)
    end

    -- Horizontal alignment: prefer API if available
    if align_w and align_w > 0 then
        if has_justify_api then
            use_justify = true
            fx = fx + math_floor(align_w * 0.5)
        else
            local dims = renderer.get_text_width(safe_str, size)
            fx = fx + math_floor((align_w - dims) * 0.5)
        end
    end

    local cache_id = cache_key or safe_str
    local should_cache = not is_dynamic_text(cache_id)
    local t_obj = should_cache and text_obj_cache[cache_id] or nil

    if not t_obj then
        t_obj = gui.text(safe_str)

        -- Only cache static text (no numbers)
        if should_cache then
            -- LRU eviction when cache is full
            if text_cache_count >= TEXT_CACHE_LIMIT then
                -- Remove oldest 30% of entries
                local evict_count = math_floor(TEXT_CACHE_LIMIT * 0.3)
                for i = 1, evict_count do
                    local old_key = text_cache_order[i]
                    if old_key then
                        text_obj_cache[old_key] = nil
                    end
                end
                -- Shift remaining entries
                local new_order = {}
                for i = evict_count + 1, text_cache_count do
                    new_order[i - evict_count] = text_cache_order[i]
                end
                text_cache_order = new_order
                text_cache_count = text_cache_count - evict_count
            end

            text_obj_cache[cache_id] = t_obj
            text_cache_count = text_cache_count + 1
            text_cache_order[text_cache_count] = cache_id
        end
    end

    t_obj:position(get_vec2(fx, fy))
    t_obj:scale(s_size)

    if use_justify then
        t_obj:justify(JUSTIFY_CENTER)
    else
        t_obj:justify(JUSTIFY_LEFT)
    end

    if override_color_vec then
        local a = override_color_vec.a
        if alpha_mult then a = a * alpha_mult end
        t_obj:color(color(override_color_vec.r, override_color_vec.g, override_color_vec.b, a))
    else
        if alpha_mult and alpha_mult < 1.0 then
            t_obj:color(renderer.get_color_alpha(col_key, alpha_mult))
        else
            t_obj:color(renderer.get_color(col_key))
        end
    end
    t_obj:draw()
end

function renderer.draw_text(str, x, y, size, col_key, align_w, align_h)
    renderer.draw_text_ex(str, x, y, size, col_key, align_w, align_h, 1.0)
end

function renderer.draw_widget_background(x, y, w, h, hover)
    renderer.draw_rect(x, y, w, h, hover and "Widget Hover" or "Widget Background", renderer.s(6))
end

function renderer.draw_slider_background(x, y, w, h, hover)
    renderer.draw_rect(x, y, w, h, hover and "Slider Background Hover" or "Slider Background", renderer.s(6))
end

-- [Image Drawing]

--- Draw an image with Vec2 caching
--- @param texture userdata The image texture (from gui.load_image)
--- @param x number X position
--- @param y number Y position
--- @param w number Width
--- @param h number Height
--- @param col userdata|nil Optional color overlay
function renderer.draw_image(texture, x, y, w, h, col)
    if not gui or not gui.image or not texture then return end

    local img = gui.image(texture, get_vec2(math_floor(x), math_floor(y)), get_vec2(math_floor(w), math_floor(h)))
    if col then
        img:color(col)
    end
    img:draw()
end

--- Draw a rounded image with Vec2 caching
--- @param texture userdata The image texture (from gui.load_image)
--- @param x number X position
--- @param y number Y position
--- @param w number Width
--- @param h number Height
--- @param rounding number Corner rounding radius
--- @param col userdata|nil Optional color overlay
function renderer.draw_image_rounded(texture, x, y, w, h, rounding, col)
    if not gui or not gui.image_rounded or not texture then return end

    local img = gui.image_rounded(texture, get_vec2(math_floor(x), math_floor(y)), get_vec2(math_floor(w), math_floor(h)), rounding)
    if col then
        img:color(col)
    end
    img:draw()
end

-- [Line, Circle, Outline, Gradient]

function renderer.draw_line(x1, y1, x2, y2, thickness, col_key, alpha_mult)
    if not gui or not gui.line then return end
    local col = alpha_mult and renderer.get_color_alpha(col_key, alpha_mult) or renderer.get_color(col_key)
    gui.line(get_vec2(math_floor(x1), math_floor(y1)), get_vec2(math_floor(x2), math_floor(y2)), thickness or 1, col)
end

function renderer.draw_circle(x, y, radius, col_key, alpha_mult)
    if not gui or not gui.circle then return end
    local col = alpha_mult and renderer.get_color_alpha(col_key, alpha_mult) or renderer.get_color(col_key)
    gui.circle(get_vec2(math_floor(x), math_floor(y)), radius, col)
end

function renderer.draw_rect_outline(x, y, w, h, thickness, col_key, round, round_flags, alpha_mult)
    if not gui or not gui.rect then return end
    local rx, ry = math_floor(x), math_floor(y)
    local rw, rh = math_floor(w), math_floor(h)
    if rw <= 0 or rh <= 0 then return end

    local r_obj = gui.rect(get_vec2(rx, ry), get_vec2(rw, rh))
    local col = alpha_mult and renderer.get_color_alpha(col_key, alpha_mult) or renderer.get_color(col_key)
    r_obj:outline(thickness or 1, col)
    if round and round > 0 then
        r_obj:rounding(math_floor(round), round_flags)
    end
    r_obj:draw()
end

function renderer.draw_rect_gradient(x, y, w, h, col_tl, col_tr, col_br, col_bl, round, round_flags)
    if not gui or not gui.rect then return end
    local rx, ry = math_floor(x), math_floor(y)
    local rw, rh = math_floor(w), math_floor(h)
    if rw <= 0 or rh <= 0 then return end

    local r_obj = gui.rect(get_vec2(rx, ry), get_vec2(rw, rh))
    r_obj:filled(true)
    r_obj:multicolors(
        renderer.get_color(col_tl),
        renderer.get_color(col_tr),
        renderer.get_color(col_br),
        renderer.get_color(col_bl)
    )
    if round and round > 0 then
        r_obj:rounding(math_floor(round), round_flags)
    end
    r_obj:draw()
end

-- [Gradient with raw color tables] For dynamic colors (e.g., notifications)
function renderer.draw_rect_gradient_raw(x, y, w, h, col_tl, col_tr, col_br, col_bl, round, round_flags)
    if not gui or not gui.rect then return end
    local rx, ry = math_floor(x), math_floor(y)
    local rw, rh = math_floor(w), math_floor(h)
    if rw <= 0 or rh <= 0 then return end

    local r_obj = gui.rect(get_vec2(rx, ry), get_vec2(rw, rh))
    r_obj:filled(true)
    -- Convert tables to color objects for multicolors API
    r_obj:multicolors(
        color(col_tl.r, col_tl.g, col_tl.b, col_tl.a),
        color(col_tr.r, col_tr.g, col_tr.b, col_tr.a),
        color(col_br.r, col_br.g, col_br.b, col_br.a),
        color(col_bl.r, col_bl.g, col_bl.b, col_bl.a)
    )
    if round and round > 0 then
        r_obj:rounding(math_floor(round), round_flags)
    end
    r_obj:draw()
end

-- Exports
function renderer.get_visual_y()
    return state.ui.window_y_start + state.ui.cy - state.ui.scroll_y
end

function renderer.is_item_visible(y, h)
    local ui = state.ui
    local min_y = ui.header_end_y + (ui.subtab_offset or 0)
    local max_y = ui.header_end_y + ui.visible_h
    return (y + (h or 0) > min_y) and (y < max_y)
end

function renderer.get_assets_dir()
    if state.deps.config_sys.get_base_dir then return state.deps.config_sys.get_base_dir() end
    return ".\\ClickUl\\"
end

-- [Text Truncation / Ellipsis]

local ellipsis_cache = {}
local ellipsis_cache_count = 0
local ELLIPSIS_CACHE_LIMIT = 512

--- Truncate text to fit within max_width, appending "..." if truncated
--- @param text string The text to truncate
--- @param font_size number The font size
--- @param max_width number The maximum width in pixels
--- @return string The truncated text (with "..." if needed)
function renderer.truncate_text(text, font_size, max_width)
    if not text or text == "" then return "" end
    if not max_width or max_width <= 0 then return text end

    local text_cache = ellipsis_cache[text]
    local size_width_key = font_size * 100000 + math_floor(max_width)

    if text_cache then
        local cached = text_cache[size_width_key]
        if cached then return cached end
    else
        text_cache = {}
        ellipsis_cache[text] = text_cache
    end

    local text_w = renderer.get_text_width(text, font_size)
    if text_w <= max_width then
        text_cache[size_width_key] = text
        ellipsis_cache_count = ellipsis_cache_count + 1
        return text
    end

    -- Need to truncate
    local ellipsis = "..."
    local ellipsis_w = renderer.get_text_width(ellipsis, font_size)
    local available_w = max_width - ellipsis_w

    if available_w <= 0 then
        text_cache[size_width_key] = ellipsis
        ellipsis_cache_count = ellipsis_cache_count + 1
        return ellipsis
    end

    -- Binary search for the best cut point
    local len = string_len(text)
    local low, high = 1, len
    local best_cut = 0

    while low <= high do
        local mid = math_floor((low + high) / 2)
        local sub = string_sub(text, 1, mid)
        local sub_w = renderer.get_text_width(sub, font_size)

        if sub_w <= available_w then
            best_cut = mid
            low = mid + 1
        else
            high = mid - 1
        end
    end

    local result
    if best_cut > 0 then
        result = string_sub(text, 1, best_cut) .. ellipsis
    else
        result = ellipsis
    end

    -- Cache the result (with size limit)
    if ellipsis_cache_count >= ELLIPSIS_CACHE_LIMIT then
        ellipsis_cache = {}
        ellipsis_cache_count = 0
    end
    text_cache[size_width_key] = result
    ellipsis_cache_count = ellipsis_cache_count + 1

    return result
end

--- Clear ellipsis cache (call when font size changes)
function renderer.clear_ellipsis_cache()
    ellipsis_cache = {}
    ellipsis_cache_count = 0
end

function renderer.clear_caches()
    vec2_cache = {}
    vec2_cache_count = 0
    clean_label_cache = {}
    clean_label_count = 0
    text_obj_cache = {}
    text_cache_order = {}
    text_cache_count = 0
    state.cache.text_width = {}
    state.cache.text_width_size = 0
    state.cache.color_alpha = {}
    color_alpha_cache_count = 0
    renderer.clear_ellipsis_cache()
    renderer.reset_color_cache()
end

function renderer.reset_color_cache()
    state.cache.color_userdata = {}
end

renderer.get_vec2 = get_vec2

return renderer
